Skip to content

WIP feat: Add new prompts API#2166

Open
Luca Forstner (lforst) wants to merge 2 commits into
mainfrom
lforst/prompts-api-guide
Open

WIP feat: Add new prompts API#2166
Luca Forstner (lforst) wants to merge 2 commits into
mainfrom
lforst/prompts-api-guide

Conversation

@lforst

@lforst Luca Forstner (lforst) commented Jun 25, 2026

Copy link
Copy Markdown
Member

This PR adds an experimental typed prompt API for defining, composing, building, and adapting Braintrust prompts in TypeScript.

Define Prompts

Prompts are defined with typed input and optional typed output schemas. Both schemas are callback-based, so the schema helper s is scoped to the right context (output schema allows different kind of values than input schema).

const classify = prompt.define({
  slug: "classify-ticket",
  model: "gpt-4o-mini",
  input: (s) =>
    s.object({
      ticket: s.string(),
    }),
  output: (s) =>
    s.object({
      label: s.enum(["bug", "question"]),
    }),
  render: ({ input }) => [
    prompt.system`Classify support tickets.`,
    prompt.user`Ticket: ${input.ticket}`,
  ],
});

Message vs String Prompts

render returns either a message array or prompt.text.

const messagesPrompt = prompt.define({
  slug: "reply",
  input: (s) => s.object({ ticket: s.string() }),
  render: ({ input }) => [prompt.user`Reply to: ${input.ticket}`], // <- returns array
});

const stringPrompt = prompt.define({
  slug: "policy",
  input: (s) => s.object({ policy: s.string() }),
  render: ({ input }) => prompt.text`Policy: ${input.policy}`, // <- returns tagged string
});

(Note: We need a tagged template for the string prompt, in order to be able to "mustachify" the string)

Prompt Composition

Input schemas can accept already-built prompts or implicitly build specific child prompt definitions.

const brandVoicePromptDefinition = prompt.define({
  slug: "brand-voice",
  input: (s) => s.object({ company: s.string(), tone: s.string() }),
  render: ({ input }) => [
    prompt.system`Use ${input.company}'s ${input.tone} voice.`,
  ],
});

const policyTextPromptDefinition = prompt.define({
  slug: "policy-text",
  input: (s) => s.object({ company: s.string(), policy: s.string() }),
  render: ({ input }) => prompt.text`${input.company}: ${input.policy}`,
});

const replyPromptDefinition = prompt.define({
  slug: "reply",
  input: (s) =>
    s.object({
      company: s.string(),
      ticket: s.string(),
      voice: s.messagesPromptDefinition(brandVoicePromptDefinition),
      policy: s.stringPromptDefinition(policyTextPromptDefinition),
    }),
  render: ({ input }) => [
    ...input.voice,
    prompt.system`Policy: ${input.policy}`,
    prompt.user`Draft a reply for: ${input.ticket}`,
  ],
});

replyPromptDefinition.build({
  company: "Braintrust",
  ticket: "The app crashes.",
  voice: { tone: "direct" },
  policy: { policy: "Keep it short." },
});

s.messagesPromptDefinition(...) and s.stringPromptDefinition(...) auto-build a specific child prompt by merging parent input with child input overrides.

Use s.builtMessagesPrompt() and s.builtStringPrompt() when callers must pass an already-built prompt.

const replyPromptDefinitionWithBuiltParts = prompt.define({
  slug: "reply-with-built-parts",
  input: (s) =>
    s.object({
      ticket: s.string(),
      voice: s.builtMessagesPrompt(),
      policy: s.builtStringPrompt(),
    }),
  render: ({ input }) => [
    ...input.voice,
    prompt.system`Policy: ${input.policy}`,
    prompt.user`Draft a reply for: ${input.ticket}`,
  ],
});

replyPromptDefinitionWithBuiltParts.build({
  ticket: "The app crashes.",
  voice: brandVoicePromptDefinition.build({ company: "Braintrust", tone: "direct" }),
  policy: policyTextPromptDefinition.build({
    company: "Braintrust",
    policy: "Keep it short.",
  }),
});

We track prompt dependencies through message spreads and string interpolation so that we can record all used prompts in traces.

Adapters

Built prompts can be converted to provider-shaped args.

const built = promptDefinition.build({ ticket: "The app crashes." });

const args = built.to(prompt.adapters.openAIChat());

String prompts are coerced to a single user message for message-based adapters.

Safe Extension

.to() always returns an object with .extend(...), which deep-merges additional args while preserving tracing metadata.

const client = new OpenAI();
const completion = await client.chat.completions.create(
  built
    .to(prompt.adapters.openAIChat())
    .extend({
      temperature: 0.2,
      tools: {
        foobar: 'baz'
      }
    })
    .extend({
      doubleExtension: 'possible'
    })
  );

We do this so that we can always instruct users to pass the prompt as a singular argument to the API, so we can properly preserve span_info.


Not in this PR

For follow-ups, the plan for pushing prompts to Braintrust is something like the following where we "send" a prompt definition to a project:

const project = braintrust.projects.create({
  name: "Example",
});

project.prompts.create(promptDefinition) // <- returns void/Promise<void> not sure yet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant